#!/usr/bin/python3
#vim: set fileencoding=utf8
# parse-kickstart - read a kickstart file and emit equivalent dracut boot args
#
# Designed to run inside the dracut initramfs environment.
# Requires python 2.7 or later.
#
#
# Copyright (C) 2012-2014 Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# the GNU General Public License v.2, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY expressed or implied, including the implied warranties of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
# Public License for more details.  You should have received a copy of the
# GNU General Public License along with this program; if not, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
# source code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission of
# Red Hat, Inc.

## XXX HACK - Monkeypatch os.urandom to use /dev/urandom not getrandom()
## XXX HACK - which will block until pool is initialized which takes forever
import os
def ks_random(num_bytes):
    return open("/dev/urandom", "rb").read(num_bytes)
os.urandom = ks_random

import sys
import logging
import shutil
import uuid
import glob
from pykickstart.parser import KickstartParser, preprocessKickstart
from pykickstart.sections import NullSection
from pykickstart.version import returnClassForVersion
from pykickstart.errors import KickstartError
# pylint: disable=wildcard-import,unused-wildcard-import
from pykickstart.constants import *
from pykickstart import commands
from collections import OrderedDict

# Default logging: none
log = logging.getLogger('parse-kickstart').addHandler(logging.NullHandler())

TMPDIR = "/tmp"

# Helper function for reading simple files in /sys
def readsysfile(f):
    '''Return the contents of f, or "" if missing.'''
    try:
        val = open(f).readline().strip()
    except IOError:
        val = ""
    return val

def read_cmdline(f):
    '''Returns an OrderedDict containing key-value pairs from a file with
    boot arguments (e.g. /proc/cmdline).'''
    args = OrderedDict()
    try:
        lines = open(f).readlines()
    except IOError:
        lines = []
    # pylint: disable=redefined-outer-name
    for line in lines:
        for arg in line.split():
            k,_,v = arg.partition("=")
            if k not in args:
                args[k] = [v]
            else:
                args[k].append(v)
    return args

def first_device_with_link():
    for dev_dir in sorted(glob.glob("/sys/class/net/*")):
        ##define ARPHRD_ETHER    1               /* Ethernet 10Mbps              */
        ##define ARPHRD_INFINIBAND 32            /* InfiniBand                   */
        try:
            with open(dev_dir+"/type") as f:
                if f.read().strip() not in ("1", "32"):
                    continue
            with open(dev_dir+"/carrier") as f:
                if f.read().strip() == "1":
                    return os.path.basename(dev_dir)
        except IOError:
            pass

    return ""

def setting_only_hostname(net, args):
    return net.hostname and (len(args) == 2 or (len(args) == 3 and "--hostname" in args))

proc_cmdline = read_cmdline("/proc/cmdline")

class DracutArgsMixin(object):
    """A mixin class to make a Command generate dracut args."""
    def dracut_args(self, args, lineno, obj):
        raise NotImplementedError

# Here are the kickstart commands we care about:

class Cdrom(commands.cdrom.FC3_Cdrom, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        return "inst.repo=cdrom"

class HardDrive(commands.harddrive.FC3_HardDrive, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        if self.biospart:
            return "inst.repo=bd:%s:%s" % (self.partition, self.dir)
        else:
            return "inst.repo=hd:%s:%s" % (self.partition, self.dir)

class NFS(commands.nfs.FC6_NFS, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        if self.opts:
            method = "nfs:%s:%s:%s" % (self.opts, self.server, self.dir)
        else:
            method="nfs:%s:%s" % (self.server, self.dir)

        # Spaces on the cmdline need to be '\ '
        method = method.replace(" ", "\\ ")
        return "inst.repo=%s" % method

class URL(commands.url.F18_Url, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        # Spaces in the url need to be %20
        if self.url:
            method = self.url.replace(" ", "%20")
        else:
            method = None

        args = ["inst.repo=%s" % method]

        if self.noverifyssl:
            args.append("rd.noverifyssl")
        if self.proxy:
            args.append("proxy=%s" % self.proxy)

        return "\n".join(args)

class Updates(commands.updates.F7_Updates, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        if self.url == "floppy":
            return "live.updates=/dev/fd0"
        elif self.url:
            return "live.updates=%s" % self.url

class MediaCheck(commands.mediacheck.FC4_MediaCheck, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        if self.mediacheck:
            return "rd.live.check"

class DriverDisk(commands.driverdisk.F14_DriverDisk, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        dd_args = []
        for dd in self.driverdiskList:
            if dd.partition:
                dd_args.append("inst.dd=hd:%s" % dd.partition)
            elif dd.source:
                dd_args.append("inst.dd=%s" % dd.source)
            elif dd.biospart:
                dd_args.append("inst.dd=bd:%s" % dd.biospart)

        return "\n".join(dd_args)

class Network(commands.network.F24_Network, DracutArgsMixin):
    def dracut_args(self, args, lineno, net):
        '''
        NOTE: The first 'network' line get special treatment:
            * '--activate' is always enabled
            * '--device' is optional (defaults to the 'ksdevice=' boot arg)
            * the device gets brought online in initramfs
        '''
        netline = None

        # Setting only hostname in kickstart
        if not net.device and not self.handler.ksdevice \
           and setting_only_hostname(net, args):
            return None

        # first 'network' line
        if len(self.network) == 1:
            net.activate = True
            # Note that there may be no net.device and no ksdevice if inst.ks=file:/ks.cfg
            # If that is the case, fall into ksnet_to_dracut with net.device=None and let
            # it handle things.
            if not net.device:
                if self.handler.ksdevice:
                    net.device = self.handler.ksdevice
                    log.info("Using ksdevice %s for missing --device in first kickstart network command", self.handler.ksdevice)
            if net.device == "link":
                net.device = first_device_with_link()
                if not net.device:
                    log.warning("No device with link found for --device=link")
                    return
                else:
                    log.info("Using %s as first device with link found", net.device)
            # tell dracut to bring this device up if it's not already done by user
            if not "ip" in proc_cmdline:
                netline = ksnet_to_dracut(args, lineno, net, bootdev=True)
        else:
            # all subsequent 'network' lines require '--device'
            if not net.device or net.device == "link":
                log.error("'%s': missing --device", " ".join(args))
                return

        # write ifcfg so NM will set up the device correctly later
        ksnet_to_ifcfg(net)

        return netline

class DisplayMode(commands.displaymode.FC3_DisplayMode, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        if self.displayMode == DISPLAY_MODE_CMDLINE:
            return "inst.cmdline"
        elif self.displayMode == DISPLAY_MODE_TEXT:
            return "inst.text"
        elif self.displayMode == DISPLAY_MODE_GRAPHICAL:
            return "inst.graphical"

class Bootloader(commands.bootloader.F21_Bootloader, DracutArgsMixin):
    def dracut_args(self, args, lineno, obj):
        if self.extlinux:
            return "extlinux"

# FUTURE: keymap, lang... device? selinux?

dracutCmds = {
    'cdrom': Cdrom,
    'harddrive': HardDrive,
    'nfs': NFS,
    'url': URL,
    'updates': Updates,
    'mediacheck': MediaCheck,
    'driverdisk': DriverDisk,
    'network': Network,
    'cmdline': DisplayMode,
    'graphical': DisplayMode,
    'text': DisplayMode,
    'bootloader': Bootloader,
}
handlerclass = returnClassForVersion()
class DracutHandler(handlerclass):
    def __init__(self):
        handlerclass.__init__(self, commandUpdates=dracutCmds)
        self.output = []
        self.ksdevice = None
    def dispatcher(self, args, lineno):
        obj = handlerclass.dispatcher(self, args, lineno)
        # and execute any specified dracut_args
        cmd = args[0]
        # the commands member is implemented by the class returned
        # by returnClassForVersion
        # pylint: disable=no-member
        command = self.commands[cmd]
        if hasattr(command, "dracut_args"):
            log.debug("kickstart line %u: handling %s", lineno, cmd)
            self.output.append(command.dracut_args(args, lineno, obj))
        return obj

def init_logger(level=None):
    if level is None and 'rd.debug' in proc_cmdline:
        level = logging.DEBUG
    logfmt = "%(name)s %(levelname)s: %(message)s"
    logging.basicConfig(format=logfmt, level=level)
    logger = logging.getLogger('parse-kickstart')
    return logger

def is_mac(addr):
    return addr and len(addr) == 17 and addr.count(":") == 5 # good enough

def find_devname(mac):
    for netif in os.listdir("/sys/class/net"):
        thismac = readsysfile("/sys/class/net/%s/address" % netif)
        if thismac.lower() == mac.lower():
            return netif

# We duplicate this in pyanaconda/network.py
def s390_settings(device):
    cfg = {
        'SUBCHANNELS': '',
        'NETTYPE': '',
        'OPTIONS': ''
        }

    subchannels = []
    for symlink in sorted(glob.glob("/sys/class/net/%s/device/cdev[0-9]*" % device)):
        subchannels.append(os.path.basename(os.readlink(symlink)))
    if not subchannels:
        return cfg
    cfg['SUBCHANNELS'] = ','.join(subchannels)

    ## cat /etc/ccw.conf
    #qeth,0.0.0900,0.0.0901,0.0.0902,layer2=0,portname=FOOBAR,portno=0
    #
    #SUBCHANNELS="0.0.0900,0.0.0901,0.0.0902"
    #NETTYPE="qeth"
    #OPTIONS="layer2=1 portname=FOOBAR portno=0"
    with open('/etc/ccw.conf') as f:
        # pylint: disable=redefined-outer-name
        for line in f:
            if cfg['SUBCHANNELS'] in line:
                items = line.strip().split(',')
                cfg['NETTYPE'] = items[0]
                cfg['OPTIONS'] = " ".join(i for i in items[1:] if '=' in i)
                break

    return cfg

def ksnet_to_dracut(args, lineno, net, bootdev=False):
    '''Translate the kickstart network data into dracut network data.'''
    # pylint: disable=redefined-outer-name
    line = []
    ip=""
    autoconf=""

    if is_mac(net.device): # this is a MAC - find the interface name
        mac = net.device
        # we need dev name to create dracut commands
        net.device = find_devname(mac)
        if net.device is None:  # iface not active - pick a name for it
            try:
                # find if 'ifname' command isn't already used for this device
                # if so use user device name
                for cmd_ifname in proc_cmdline["ifname"]:
                    cmd_ifname, cmd_mac= cmd_ifname.split(":", 1)
                    if mac == cmd_mac:
                        net.device = cmd_ifname
                        log.info("MAC '%s' is named by user. Use '%s' name." % (mac, cmd_ifname))
                        break
            except KeyError:
                log.debug("ifname= command isn't used generate name ksdev0 for device")
        # if the device is still None use ksdev0 name
        if net.device is None:
            net.device = "ksdev0" # we only get called once, so this is OK
            line.append("ifname=%s:%s" % (net.device, mac.lower()))

    # NOTE: dracut currently only does ipv4 *or* ipv6, so only one ip=arg..
    if net.bootProto in (BOOTPROTO_DHCP, BOOTPROTO_BOOTP):
        autoconf="dhcp"
    elif net.bootProto == BOOTPROTO_IBFT:
        autoconf="ibft"
    elif net.bootProto == BOOTPROTO_QUERY:
        log.error("'%s': --bootproto=query is deprecated", " ".join(args))
    elif net.bootProto == BOOTPROTO_STATIC:
        req = ("gateway", "netmask", "nameserver", "ip")
        missing = ", ".join("--%s" % i for i in req if not hasattr(net, i))
        if missing:
            log.warning("line %u: network missing %s", lineno, missing)
        else:
            ip="{0.ip}::{0.gateway}:{0.netmask}:" \
               "{0.hostname}:{0.device}:none:{0.mtu}".format(net)
    elif net.ipv6 == "auto":
        autoconf="auto6"
    elif net.ipv6 == "dhcp":
        autoconf="dhcp6"
    elif net.ipv6:
        ip="[{0.ipv6}]::{0.ipv6gateway}:{0.netmask}:" \
           "{0.hostname}:{0.device}:none:{0.mtu}".format(net)

    if autoconf:
        if net.device or net.mtu:
            ip="%s:%s:%s" % (net.device, autoconf, net.mtu)
        else:
            ip=autoconf

    if ip:
        line.append("ip=%s" % ip)

    for ns in net.nameserver.split(","):
        if ns:
            line.append("nameserver=%s" % ns)

    if bootdev:
        if net.device:
            line.append("bootdev=%s" % net.device)
        # touch /tmp/net.ifaces to make sure dracut brings up network
        open(TMPDIR+"/net.ifaces", "a")

    if net.essid or net.wepkey or net.wpakey:
        # NOTE: does dracut actually support wireless? (do we care?)
        log.error("'%s': dracut doesn't support wireless networks",
                      " ".join(args))

    return " ".join(line)

def add_s390_settings(dev, ifcfg):
    s390cfg = s390_settings(dev)
    if s390cfg['SUBCHANNELS']:
        ifcfg.pop('HWADDR', None)
        ifcfg['SUBCHANNELS'] = s390cfg['SUBCHANNELS']
    if s390cfg['NETTYPE']:
        ifcfg['NETTYPE'] = s390cfg['NETTYPE']
    if s390cfg['OPTIONS']:
        ifcfg['OPTIONS'] = s390cfg['OPTIONS']

def ksnet_to_ifcfg(net, filename=None):
    '''Write an ifcfg file for the given kickstart network config'''
    dev = net.device
    if is_mac(dev):
        dev = find_devname(dev)
    if not dev:
        return
    if (not os.path.isdir("/sys/class/net/%s" % dev)
        and not net.bondslaves and not net.teamslaves and not net.bridgeslaves):
        log.info("can't find device %s", dev)
        return
    ifcfg = dict()
    if filename is None:
        filename = TMPDIR+"/ifcfg/ifcfg-%s" % dev
        if not os.path.isdir(TMPDIR+"/ifcfg"):
            os.makedirs(TMPDIR+"/ifcfg")
    ifcfg['DEVICE'] = dev
    hwaddr = readsysfile("/sys/class/net/%s/address" % dev)
    if "ifname={0}:{1}".format(dev, hwaddr).upper() in open("/proc/cmdline").read().upper():
        # rename by initscript's 60-net-rules on target system after switchroot
        ifcfg['HWADDR'] = hwaddr
    ifcfg['UUID'] = str(uuid.uuid4())
    # we set real ONBOOT value in anaconda, here
    # we use it to activate devcies by NM on start
    ifcfg['ONBOOT'] = "yes" if net.activate else "no"

    add_s390_settings(dev, ifcfg)

    # dhcp etc.
    if net.bootProto != "":
        ifcfg['BOOTPROTO'] = net.bootProto
    if net.bootProto == 'static':
        ifcfg['IPADDR'] = net.ip
        ifcfg['NETMASK'] = net.netmask
        ifcfg['GATEWAY'] = net.gateway
    if net.bootProto == 'dhcp':
        if net.hostname:
            ifcfg['DHCP_HOSTNAME'] = net.hostname

    # ipv6 settings
    if net.noipv6:
        ifcfg['IPV6INIT'] = "no"
    else:
        ifcfg['IPV6INIT'] = "yes"

        if net.ipv6 == 'dhcp':
            ifcfg['DHCPV6C'] = "yes"
            ifcfg['IPV6_AUTOCONF'] = "no"
            if net.ipv6gateway:
                ifcfg['IPV6_DEFAULTGW'] = net.ipv6gateway
        elif net.ipv6 == 'auto':
            ifcfg['IPV6_AUTOCONF'] = "yes" # NOTE: redundant (this is the default)
        elif ':' in net.ipv6:
            ifcfg['IPV6ADDR'] = net.ipv6
            ifcfg['IPV6_AUTOCONF'] = "no"

    # misc stuff
    if net.mtu:
        ifcfg['MTU'] = net.mtu
    if net.nameserver:
        for i, ip in enumerate(net.nameserver.split(",")):
            ifcfg["DNS%d" % (i+1)] = ip
    if net.nodefroute:
        ifcfg['DEFROUTE'] = "no"

    # FUTURE: dhcpclass, ethtool, essid/wepkey/wpakay, etc.

    if net.bootProto == 'dhcp':
        srcpath = TMPDIR+"/dhclient.%s.lease" % dev
        dstdir = TMPDIR+"/ifcfg-leases"
        dstpath = "%s/dhclient-%s-%s.lease" % (dstdir, ifcfg['UUID'], dev)
        if os.path.exists(srcpath):
            if not os.path.isdir(dstdir):
                os.makedirs(dstdir)
            shutil.copyfile(srcpath, dstpath)

    if net.bondslaves:
        ifcfg.pop('HWADDR', None)
        ifcfg['TYPE'] = "Bond"
        ifcfg['BONDING_MASTER'] = "yes"
        ifcfg['NAME'] = "Bond connection %s" % dev
        if ';' in net.bondopts:
            sep = ";"
        else:
            sep = ","
        ifcfg['BONDING_OPTS'] = " ".join(net.bondopts.split(sep))

        for i, slave in enumerate(net.bondslaves.split(","), 1):
            slave_ifcfg = {
                            'TYPE' : "Ethernet",
                            'NAME' : "%s slave %s" % (dev, i),
                            'UUID' : str(uuid.uuid4()),
                            'ONBOOT' : "yes",
                            'MASTER' : ifcfg['UUID'],
                            'HWADDR' : readsysfile("/sys/class/net/%s/address" % slave),
                          }
            add_s390_settings(slave, slave_ifcfg)
            slave_filename = TMPDIR+"/ifcfg/ifcfg-%s" % "_".join(slave_ifcfg['NAME'].split(" "))
            log.info("writing ifcfg %s for slave %s of bond %s", slave_filename, slave, dev)
            write_ifcfg(slave_filename, slave_ifcfg)

    if net.teamslaves:

        ifcfg.pop('HWADDR', None)
        ifcfg['TYPE'] = "Team"
        ifcfg['NAME'] = "Team connection %s" % dev
        ifcfg['TEAM_CONFIG'] = net.teamconfig

        for i, (slave, cfg) in enumerate(net.teamslaves):
            slave_ifcfg = {
                            'DEVICE': slave,
                            'DEVICETYPE' : "TeamPort",
                            'NAME' : "%s slave %s" % (dev, i),
                            'UUID' : str(uuid.uuid4()),
                            'ONBOOT' : "yes",
                            'TEAM_MASTER' : dev,
#                            'HWADDR' : readsysfile("/sys/class/net/%s/address" % slave),
                          }
            if cfg:
                slave_ifcfg['TEAM_PORT_CONFIG'] = cfg

            slave_filename = TMPDIR+"/ifcfg/ifcfg-%s" % "_".join(slave_ifcfg['NAME'].split(" "))
            log.info("writing ifcfg %s for slave %s of team %s", slave_filename, slave, dev)
            write_ifcfg(slave_filename, slave_ifcfg)

    if net.bridgeslaves:

        ifcfg.pop('HWADDR', None)
        ifcfg['TYPE'] = "Bridge"
        ifcfg['NAME'] = "Bridge connection %s" % dev

        options = {}
        for opt in net.bridgeopts.split(","):
            key, _, value = opt.partition("=")
            if not value:
                log.error("Invalid bridge option %s", opt)
                continue
            key = key.replace('-', '_')
            options[key] = value
        stp = options.pop("stp", None)
        if stp:
            ifcfg['STP'] = stp
        delay = options.pop("forward_delay", None)
        if delay:
            ifcfg['DELAY'] = delay
        if options:
            keyvalues = ["%s=%s" % (key, options[key]) for key in options]
            ifcfg['BRIDGING_OPTS'] = " ".join(keyvalues)

        for i, slave in enumerate(net.bridgeslaves.split(","), 1):
            slave_ifcfg = {
                            'TYPE' : "Ethernet",
                            'NAME' : "%s slave %s" % (dev, i),
                            'UUID' : str(uuid.uuid4()),
                            'ONBOOT' : "yes",
                            'BRIDGE' : dev,
                            'HWADDR' : readsysfile("/sys/class/net/%s/address" % slave),
                          }
            slave_filename = TMPDIR+"/ifcfg/ifcfg-%s" % "_".join(slave_ifcfg['NAME'].split(" "))
            log.info("writing ifcfg %s for slave %s of bridge %s", slave_filename, slave, dev)
            write_ifcfg(slave_filename, slave_ifcfg)

    if net.vlanid:
        interface_name = net.interfacename or "%s.%s" % (dev, net.vlanid)
        ifcfg.pop('HWADDR', None)
        ifcfg['TYPE'] = "Vlan"
        ifcfg['VLAN'] = "yes"
        ifcfg['VLAN_ID'] = net.vlanid
        ifcfg['NAME'] = "VLAN connection %s" % interface_name
        ifcfg['DEVICE'] = interface_name
        ifcfg['PHYSDEV'] = dev
        filename = TMPDIR+"/ifcfg/ifcfg-%s" % interface_name
        if net.bondslaves:
            bond_ifcfg = {
                           'TYPE' : "Bond",
                           'NAME' : "Bond connection %s" % dev,
                           'UUID' : ifcfg['UUID'],
                           'ONBOOT' : ifcfg['ONBOOT'],
                           'BONDING_MASTER' : ifcfg['BONDING_MASTER'],
                           'BONDING_OPTS' : ifcfg['BONDING_OPTS'],
                           'DEVICE' : dev,
                         }
            bond_filename = TMPDIR+"/ifcfg/ifcfg-%s" % dev
            log.info("writing parent bond ifcfg %s for vlan %s", bond_filename, interface_name)
            write_ifcfg(bond_filename, bond_ifcfg)
        ifcfg.pop('BONDING_OPTS', None)
        ifcfg.pop('BONDING_MASTER', None)
        ifcfg['UUID'] = str(uuid.uuid4())

    log.info("writing ifcfg %s for %s", filename, dev)
    if write_ifcfg(filename, ifcfg):
        return filename

def write_ifcfg(filename, ifcfg):
    try:
        with open(filename, "w") as f:
            f.write('# Generated by parse-kickstart\n')
            for k,v in list(ifcfg.items()):
                f.write('%s="%s"\n' % (k,v.replace('"', '\\"')))
    except IOError as e:
        log.error("can't write %s: %s", filename, e)
        return False
    return True

def process_kickstart(ksfile):
    handler = DracutHandler()
    try:
        # if the ksdevice key is present the first item must be there
        # and it should be only once (ignore the orthers)
        handler.ksdevice = proc_cmdline['ksdevice'][0]
    except KeyError:
        log.debug("ksdevice argument is not available")
    parser = KickstartParser(handler, missingIncludeIsFatal=False, errorsAreFatal=False)
    parser.registerSection(NullSection(handler, sectionOpen="%addon"))
    parser.registerSection(NullSection(handler, sectionOpen="%anaconda"))
    log.info("processing kickstart file %s", ksfile)
    processed_file = preprocessKickstart(ksfile)
    try:
        parser.readKickstart(processed_file)
    except KickstartError as e:
        log.error(str(e))
    with open(TMPDIR+"/ks.info", "a") as f:
        f.write('parsed_kickstart="%s"\n' % processed_file)
    log.info("finished parsing kickstart")
    return processed_file, handler.output

if __name__ == '__main__':
    log = init_logger()

    # Override tmp directory path for testing. Don't use argparse because we don't want to
    # include that dependency in the initramfs. Pass '--tmpdir /path/to/tmp/'
    if "--tmpdir" in sys.argv:
        idx = sys.argv.index("--tmpdir")
        try:
            sys.argv.pop(idx)
            TMPDIR = os.path.normpath(sys.argv.pop(idx))
        except IndexError:
            pass

    for path in sys.argv[1:]:
        outfile, output = process_kickstart(path)
        for line in (l for l in output if l):
            print(line)
